
系列:30 天打造企業級 PLM|面向:全端
一台整機的 BOM 展開是幾千個節點。最直覺的寫法,遞迴查每個子件的子件,會產生幾千次資料庫往返;一次全撈又怕撐爆瀏覽器。更麻煩的是髒資料:如果 A 的 BOM 裡有 B、B 的 BOM 裡又有 A(歷史資料常有這種循環),天真的遞迴直接無窮迴圈。今天講 Mini-PLM 的 BOM 全樹 API 怎麼同時解掉這三題。
實機畫面,BOM 樹狀表格,子階展開、縮排呈現:

MODIFY_BOM 權限)。放行過的結構不可直改,這是追溯性的底線mp_bom_item)走,不跟著料號走以前在 Oracle Agile PLM 中,多階 BOM 展開是極具破壞力的效能殺手。
在舊 EJB 架構與 Oracle 資料庫環境下:
CONNECT BY 遞迴查詢,一旦歷史資料中存在循環參照(A 包含 B,B 包含 A),資料庫直接噴出 ORA-01436: CONNECT BY loop in user data,導致整張 BOM 畫面瞬間癱瘓,甚至在 EJB 遞迴走訪時引爆 StackOverflowError。Mini-PLM 採用 BFS 逐層批次查詢 + 記憶體組裝,並內建「祖先路徑循環防禦」與「節點上限截斷」機制,讓複雜的多階 BOM 展開既安全又高效。
| 遞迴逐節點 | BFS 逐層批次 | |
|---|---|---|
| 查詢次數 | O(節點數) | O(深度) |
| 程式直覺度 | 高 | 中 |
| N+1 風險 | 就是 N+1 本人 | 天然免疫 |
選 BFS 批次,查詢數只跟深度成正比,跟節點總數無關。十層、五千節點的樹,只需要約十次批次查詢。BomService.getBomTree() 的 javadoc 直接把複雜度寫進規格:
/**
* 子層以 BFS 逐層批次查詢(查詢數 ~O(深度),與節點總數無關),不套 redline。
* 防護:祖先路徑循環偵測(cycleDetected)、深度上限、節點總數上限(truncated)。
*/
全樹 API 一次回傳、antd tree table 負責展開收合。沒做懶載入,原因是 BOM 的使用模式是展開來回看、比對,懶載入每展一節點打一次 API 反而卡;上限保護(深度與節點數)已在後端把回應規模鎖住。懶載入是手段不是信仰,資料規模有上限保護時,一次載入的體驗更好。
看樹之外還要建樹。一顆一顆料號打進去掛 BOM 是折磨,Mini-PLM 讓料號可以用拖的:搜尋結果、結果追蹤面板裡的品項列都是拖曳來源,拖到 BOM 區放開就掛上去。底層是一套全站共用的 HTML5 原生 dataTransfer 協定,payload 是 JSON:
// 單筆:{ type: "ITEM", id, number }
// 多筆:側欄面板多選後拖出,自動升級成批次 payload
event.dataTransfer.setData("application/json", JSON.stringify({
type: "ITEM_BATCH",
items: dragItems.map((itemMeta) => ({ id: String(itemMeta.resourceId), number: itemMeta.number })),
}));
這裡刻意不用 @dnd-kit。dnd-kit 的關鍵約束是拖放兩端必須包在同一個 DndContext 底下(Day 4 表單設計器正是為此把 DndContext 放到頂層),但物件拖放的來源與目標散在完全不同的元件樹——搜尋頁、側欄面板、品項頁——甚至未來可能跨瀏覽器分頁。HTML5 原生協定是瀏覽器層的,天然跨樹;代價是要自己定義 payload 格式與型別防禦(JSON.parse 出來先當 unknown 逐層判別)。元件內排序用 dnd-kit,跨頁面搬運物件用原生協定,兩套並存、各司其職。
接收端 ItemPage.handleBomDrop 有兩層設計值得抄。第一層是守門前置:canDirectEditBom(Draft 初版加 MODIFY_BOM 權限,就是本篇商業邏輯講的直改條件)不成立、或行編輯進行中,直接不收這次 drop,而不是收了再報錯。第二層是部分成功的誠實回報:批次掛載逐筆執行、成功失敗分開記,最後彈出 Drop Summary 總結(總數、成功數、失敗數與逐筆失敗原因)。三十顆料掛上二十八顆,剩兩顆重複或無權限,正確的行為既不是全部回滾、也不是靜默吞掉失敗,而是把帳算清楚給使用者看。這個 Summary 模式在表單夾帶(Day 16 會遇到)原樣複用。
// BFS 逐層展開:level 為「即將產生的子層」深度(1..maxDepth-1)
for (int level = 1; level < maxDepth && !frontier.isEmpty() && !truncated; level++) {
// 1) 篩出本層可展開的節點(有子 BOM、可解析版本、未形成循環)
for (BomTreePendingNode pending : frontier) {
if (pending.ancestorItemIds.contains(childItemId)) {
// 子件已出現在祖先路徑 → 循環,標記後不往下展開
pending.node.setCycleDetected(Boolean.TRUE);
log.warn("BOM 樹展開偵測到循環參照,停止下鑽...");
continue;
}
expandable.add(pending);
childRevisionIds.add(childRevisionId);
}
// 2) 批次查下一層 BOM 行並依父版本分組(一層一查,不是一節點一查)
List<BomItem> levelItems = bomItemRepository
.findByParentRevisionIdIn(new ArrayList<>(childRevisionIds));
// 3) 掛 children 並組下一輪 frontier
...
}
第一道防護最有意思:循環偵測用祖先路徑,不用全域 visited。每個 frontier 節點帶著自己的 ancestorItemIds。同一顆螺絲出現在樹的兩個分支是正常的(不是循環),只有子件出現在自己的祖先鏈上才是循環,全域 visited 會把正常的共用料誤殺。偵測到循環是標記該節點(cycleDetected,前端可視化警示)而不是拋錯,一條髒資料不該讓整棵樹看不了。
第二道是深度上限,預設 10 層、外部可調但有硬上限,防的是就算沒有循環、超深的樹也能拖垮回應。第三道是節點總數上限:超限就截斷,回應標 truncated=true,前端顯示已截斷,而不是假裝樹只有這麼大。有上限就要有告知,靜默截斷等於對資料正確性說謊。
Day 13 的預備版本要複製整棵 BOM。逐筆 entityManager.persist 複製一千行 BOM,Hibernate 的 dirty checking 與逐筆 INSERT 讓它慢到以秒計;改用 BomBulkCopyDao 的 INSERT-SELECT,一條 SQL 在資料庫端整批複製,千行等級的複製從秒級降到毫秒級。ORM 的甜蜜區是單筆與小批次,整批資料搬移就該回到 SQL。這是 Day 21 壓測抓出「BOM 掛載 2.8 秒」熱點後的修法之一。
BFS 批次讓查詢數只看深度,循環偵測用祖先路徑並標記不拋錯,上限保護配 truncated 誠實告知,整樹複製回歸 SQL。四個決策拼起來,幾千節點的 BOM 展開才快得起來、也才炸不掉。明日 Day 15:Redline——改了什麼,怎麼讓簽核的人一眼看懂。